fix: charge collateral when a CoinJoin session aborts - #7568
fix: charge collateral when a CoinJoin session aborts#7568PastaPastaPasta wants to merge 10 commits into
Conversation
nSessionDenom was the one CCoinJoinBaseSession field that was neither atomic nor guarded, while its siblings nState, nSessionID and nTimeLastSuccessfulStep are all std::atomic. On the server it is written by the message-handling thread in CreateNewSession() and by the scheduler thread in SetNull(), and read without any lock by CheckForCompleteQueue(), AddUserToExistingSession(), IsValidInOuts(), the relay logging, and by RPC threads via GetJsonInfo(). Concurrent unsynchronized access to a plain int is a data race: benign on the hardware we support, but formally UB and reportable by TSan.
Potential PR merge conflictsThis is advisory only. It does not block CI, but it marks PRs that will likely need a rebase depending on merge order. If this PR merges firstThese open PRs will likely need a rebase:
If these PRs merge firstThis PR will likely need a rebase:
|
|
✅ Final review complete — no blockers (commit 9777136) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2bc5dbd248
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n", | ||
| (nState == POOL_STATE_SIGNING) ? "Signing" : "Session"); | ||
| if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) { | ||
| collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT); |
There was a problem hiding this comment.
Exclude in-flight entries from guaranteed timeout penalties
When a valid DSVIN arrives before the deadline but AddEntry() is still blocked in IsCollateralValid() or IsValidInOuts() outside cs_coinjoin, the scheduler can observe fewer than the minimum committed entries here and classify that participant as a missing submitter. With three reservations, two committed entries, and the third validation in flight, this path deterministically selects and consumes the third participant's collateral, resets the session, and then makes the already-received entry fail its session revalidation. Track in-flight submissions or serialize this cutoff with entry processing so an honest pre-deadline submission cannot receive the guaranteed abort penalty.
AGENTS.md reference: AGENTS.md:L173-L175
Useful? React with 👍 / 👎.
WalkthroughCoinJoin session denomination is now atomic, and validation uses captured denomination values. Server collateral state is consolidated in a lock-protected container. Pool checks, message handling, entry admission, finalization, and timeout processing use session snapshots and lock coordination. Fee selection supports probabilistic and guaranteed-abort policies. Tests cover collateral selection, timeout ordering, in-flight messages, pool locking, session recovery, and concurrent denomination validation. Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant CoinJoinClient
participant CCoinJoinServer
participant cs_coinjoin
participant SessionCollaterals
participant Mempool
CoinJoinClient->>CCoinJoinServer: submit entry or signing message
CCoinJoinServer->>cs_coinjoin: capture and validate session state
CCoinJoinServer->>SessionCollaterals: check or store collateral
CCoinJoinServer->>cs_coinjoin: finalize session or update state
CCoinJoinServer->>Mempool: process final transaction
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/coinjoin/coinjoin.h (1)
345-350: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCorrect the logging explanation.
tinyformatformatsstd::atomic<int>through its implicit conversion toint.LogPrint()accepts arguments byconstreference, which avoids copying the non-copyable atomic.WalletCJLogPrint()forwards toCWallet::WalletLogPrintf, whose parameters are passed by value, so.load()is required to pass anint.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/coinjoin/coinjoin.h` around lines 345 - 350, Update the comments above nSessionDenom to accurately explain that tinyformat uses the atomic’s implicit int conversion, LogPrint() accepts it by const reference without copying, and WalletCJLogPrint() forwards to CWallet::WalletLogPrintf with by-value parameters, requiring an explicit .load().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/coinjoin/coinjoin.h`:
- Around line 345-350: Update the comments above nSessionDenom to accurately
explain that tinyformat uses the atomic’s implicit int conversion, LogPrint()
accepts it by const reference without copying, and WalletCJLogPrint() forwards
to CWallet::WalletLogPrintf with by-value parameters, requiring an explicit
.load().
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7c98ea00-f5ed-46f1-b28d-035fcce2d4d4
📒 Files selected for processing (5)
src/coinjoin/client.cppsrc/coinjoin/coinjoin.hsrc/coinjoin/server.cppsrc/coinjoin/server.hsrc/test/coinjoin_inouts_tests.cpp
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
The guaranteed abort-fee policy can still consume collateral from honest submissions already being processed, and a separate TRY_LOCK interleaving can reset sessions that remain recoverable. The commit stack also contains four syntactically unbuildable intermediate commits, while the offender-deduplication behavior change is obscured by a refactor-only commit subject.
Source: reviewer backends: gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier backend: gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and is not reviewer evidence.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— dash-core-commit-history (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 3 blocking | 🟡 1 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/server.cpp`:
- [BLOCKING] src/coinjoin/server.cpp:625-628: Exclude in-flight submissions from guaranteed timeout penalties
`SelectCollateralToCharge()` only recognizes entries already committed to `vecEntries`, but `AddEntry()` releases `cs_coinjoin` while running `IsCollateralValid()` and `IsValidInOuts()` at lines 759-794. A valid `DSVIN` whose processing began before the deadline can therefore still be validating when the scheduler observes the timeout. With three reservations, two committed entries, and the third entry in flight, the third participant is classified as the only missing submitter, selected with certainty, and charged after `SetNull()` makes its final session revalidation fail. Signing has the same gap while `DSSIGNFINALTX` is being decoded or between its per-input `AddScriptSig()` calls. Track in-flight messages for the current session or serialize the timeout cutoff with their complete processing before applying a guaranteed collateral penalty.
- [BLOCKING] src/coinjoin/server.cpp:610-628: Preserve recoverable finalization when the preceding pool check is skipped
`CheckTimeout()` assumes the immediately preceding `CheckPool()` handled every recoverable accepting-entry timeout, but `CheckPool()` uses a non-blocking `TRY_LOCK`. A message-handling thread can hold `cs_check_pool`, sample `HasTimedOut()` as false immediately before the deadline, and then release the mutex after the scheduler's `CheckPool()` has skipped it but before the scheduler calls `CheckTimeout()`. `CheckTimeout()` then acquires the mutex after the deadline and unconditionally resets the session. If the session has at least `GetMinPoolParticipants()` committed entries but fewer entries than reservations, it should enter `ChargeAndFinalize` and retain the probabilistic policy; this interleaving instead aborts it and applies `GUARANTEED_ON_ABORT`. Re-evaluate the full accepting-entry action after acquiring `cs_check_pool` rather than relying on a preceding check that may have been skipped or sampled an earlier time.
- [BLOCKING] src/coinjoin/server.cpp:634: Fold the stray-brace correction into its introducing commit
Commit `67c9647ed03` introduces an extra closing brace immediately after `CCoinJoinServer::CheckTimeout()`. The unmatched brace remains in `264ba3fdfa7`, `76e366e1ceb`, and `9e199087637`, and is only removed by the final commit `2bc5dbd248b`. Those four intermediate commits are syntactically unbuildable and unusable as `git bisect` points. Amend `67c9647ed03` to omit the extra brace, remove the corrective deletion from the final commit, and rebase the intervening commits so every permanent-history state builds independently.
- [SUGGESTION] src/coinjoin/server.cpp:503-509: Make offender deduplication explicit in the commit history
Commit `9e199087637` is titled `refactor: separate CoinJoin offender selection policy`, but it also changes observable behavior: signing offenders are added once per entry instead of once per unsigned input. This removes the extra random-selection weight previously assigned to participants with multiple unsigned inputs. Move the deduplication hunk into the behavioral fix commit or reword `9e199087637` and add a commit body explaining the weighting change so `git log` and `git blame` expose the functional change.
| if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) { | ||
| collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT); | ||
| } | ||
| SetNull(); |
There was a problem hiding this comment.
🔴 Blocking: Exclude in-flight submissions from guaranteed timeout penalties
SelectCollateralToCharge() only recognizes entries already committed to vecEntries, but AddEntry() releases cs_coinjoin while running IsCollateralValid() and IsValidInOuts() at lines 759-794. A valid DSVIN whose processing began before the deadline can therefore still be validating when the scheduler observes the timeout. With three reservations, two committed entries, and the third entry in flight, the third participant is classified as the only missing submitter, selected with certainty, and charged after SetNull() makes its final session revalidation fail. Signing has the same gap while DSSIGNFINALTX is being decoded or between its per-input AddScriptSig() calls. Track in-flight messages for the current session or serialize the timeout cutoff with their complete processing before applying a guaranteed collateral penalty.
source: ['codex']
There was a problem hiding this comment.
Resolved in this update — Exclude in-flight submissions from guaranteed timeout penalties no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
| // CheckPool can be finalizing or committing on the message-handling thread. Skipping this tick | ||
| // keeps timeout reset and finalization/commit single-flight without blocking the scheduler. | ||
| TRY_LOCK(cs_check_pool, lock_check_pool); | ||
| if (!lock_check_pool) return; | ||
|
|
||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n", | ||
| (nState == POOL_STATE_SIGNING) ? "Signing" : "Session"); | ||
| ChargeFees(); | ||
| WITH_LOCK(cs_coinjoin, SetNull()); | ||
| CTransactionRef collateral_to_charge; | ||
| { | ||
| LOCK(cs_coinjoin); | ||
|
|
||
| // Too early to do anything. Recheck while holding the lock so selecting an offender and | ||
| // closing the session form one atomic cutoff for late entries and signatures. | ||
| if (!CCoinJoinServer::HasTimedOut()) return; | ||
|
|
||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n", | ||
| (nState == POOL_STATE_SIGNING) ? "Signing" : "Session"); | ||
| if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) { | ||
| collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT); | ||
| } | ||
| SetNull(); |
There was a problem hiding this comment.
🔴 Blocking: Preserve recoverable finalization when the preceding pool check is skipped
CheckTimeout() assumes the immediately preceding CheckPool() handled every recoverable accepting-entry timeout, but CheckPool() uses a non-blocking TRY_LOCK. A message-handling thread can hold cs_check_pool, sample HasTimedOut() as false immediately before the deadline, and then release the mutex after the scheduler's CheckPool() has skipped it but before the scheduler calls CheckTimeout(). CheckTimeout() then acquires the mutex after the deadline and unconditionally resets the session. If the session has at least GetMinPoolParticipants() committed entries but fewer entries than reservations, it should enter ChargeAndFinalize and retain the probabilistic policy; this interleaving instead aborts it and applies GUARANTEED_ON_ABORT. Re-evaluate the full accepting-entry action after acquiring cs_check_pool rather than relying on a preceding check that may have been skipped or sampled an earlier time.
source: ['codex']
There was a problem hiding this comment.
Resolved in this update — Preserve recoverable finalization when the preceding pool check is skipped no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
| if (collateral_to_charge) { | ||
| ConsumeCollateral(collateral_to_charge); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: Fold the stray-brace correction into its introducing commit
Commit 67c9647ed03 introduces an extra closing brace immediately after CCoinJoinServer::CheckTimeout(). The unmatched brace remains in 264ba3fdfa7, 76e366e1ceb, and 9e199087637, and is only removed by the final commit 2bc5dbd248b. Those four intermediate commits are syntactically unbuildable and unusable as git bisect points. Amend 67c9647ed03 to omit the extra brace, remove the corrective deletion from the final commit, and rebase the intervening commits so every permanent-history state builds independently.
source: ['codex']
There was a problem hiding this comment.
Resolved in this update — Fold the stray-brace correction into its introducing commit no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
| // who didn't sign? Include each participant once even if multiple inputs are unsigned. | ||
| for (const auto& entry : vecEntries) { | ||
| for (const auto& txdsin : entry.vecTxDSIn) { | ||
| if (!txdsin.fHasSig) { | ||
| LogPrint(BCLog::COINJOIN, /* Continued */ | ||
| "CCoinJoinServer::ChargeFees -- found uncooperative node (didn't sign), found offence\n"); | ||
| vecOffendersCollaterals.push_back(entry.txCollateral); | ||
| } | ||
| if (std::ranges::any_of(entry.vecTxDSIn, [](const auto& txdsin) { return !txdsin.fHasSig; })) { | ||
| LogPrint(BCLog::COINJOIN, /* Continued */ | ||
| "CCoinJoinServer::SelectCollateralToCharge -- found uncooperative node (didn't sign), " | ||
| "found offence\n"); | ||
| vecOffendersCollaterals.push_back(entry.txCollateral); |
There was a problem hiding this comment.
🟡 Suggestion: Make offender deduplication explicit in the commit history
Commit 9e199087637 is titled refactor: separate CoinJoin offender selection policy, but it also changes observable behavior: signing offenders are added once per entry instead of once per unsigned input. This removes the extra random-selection weight previously assigned to participants with multiple unsigned inputs. Move the deduplication hunk into the behavioral fix commit or reword 9e199087637 and add a commit body explaining the weighting change so git log and git blame expose the functional change.
source: ['codex']
There was a problem hiding this comment.
Resolved in this update — Make offender deduplication explicit in the commit history no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
2bc5dbd to
3d5cf6b
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3d5cf6b6e0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n", | ||
| (nState == POOL_STATE_SIGNING) ? "Signing" : "Session"); | ||
| if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) { | ||
| collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT); |
There was a problem hiding this comment.
Do not charge peers that never received the final transaction
When RelayFinalTransaction() encounters one disconnected entry, it stops relaying and calls RelayStatus(STATUS_REJECTED), which tells every connected client to abort, but the server remains in POOL_STATE_SIGNING unless all entries are disconnected. At the subsequent timeout, this guaranteed policy classifies unsigned entries—including connected peers after the failed relay that never received DSFINALTX—as offenders and consumes one at random. Thus a single disconnect can cost an honest participant its collateral; this coordinator-side relay-failure path should reset without charging, or only peers that were successfully sent the final transaction should be eligible.
AGENTS.md reference: AGENTS.md:L168-L168
Useful? React with 👍 / 👎.
thepastaclaw
left a comment
There was a problem hiding this comment.
Preliminary review — Codex only
All four prior verified findings are resolved at the current head, but two signing-timeout paths still block approval: guaranteed abort charging can penalize honest participants after the server tells them to stop, and a stale pool-check interleaving can discard a fully signed transaction. The stack also has two non-blocking history issues: the lint-only follow-up should be folded into its introducing commits, and the subtle timeout fixes should retain their rationale in commit bodies.
Source: reviewer backends: gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier backend: gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.
Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— dash-core-commit-history (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet: not run (deferred by blocker gate)
🔴 2 blocking | 🟡 2 suggestion(s)
2 additional finding(s) omitted (not in diff).
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `src/coinjoin/server.cpp`:
- [BLOCKING] src/coinjoin/server.cpp:681-684: Do not guarantee a fee after the coordinator aborts signing
The guaranteed timeout policy applies even after the server has instructed connected clients to stop signing. `RelayFinalTransaction()` calls `RelayStatus(STATUS_REJECTED)` when any entry is disconnected, and `ProcessDSSIGNFINALTX()` does the same after any `AddScriptSig()` failure. Connected clients process that rejection by entering `POOL_STATE_ERROR` and releasing their session resources, but the server remains in `POOL_STATE_SIGNING` unless every entry is disconnected. A malicious participant can exploit this by submitting its valid signature and then resubmitting it: the duplicate fails, every honest peer is told to abort, and the malicious participant is excluded from the unsigned-offender set. At timeout, this block then guarantees that one honest participant is charged. A failed `DSFINALTX` relay can similarly charge a connected entry that never received the transaction. Coordinator-originated signing aborts must reset without the guaranteed fee, or eligibility must be limited to participants that received the final transaction and were not subsequently instructed to abort.
- [BLOCKING] src/coinjoin/server.cpp:669-684: Commit complete signatures when rechecking a timeout
`CheckTimeout()` re-evaluates recoverable accepting-entry sessions but does not re-evaluate `IsSignaturesComplete()` for signing sessions. A scheduler `CheckPool()` can acquire `cs_check_pool`, observe the signatures as incomplete, and release `cs_coinjoin`. The final on-time `DSSIGNFINALTX` can then add its signature, skip its own `CheckPool()` because the scheduler still owns `cs_check_pool`, and clear its in-flight guard. When the scheduler subsequently enters `CheckTimeout()` after the deadline, there are no unsigned offenders, but this block still calls `SetNull()` and discards the fully signed transaction. Re-evaluate the signing action under `cs_coinjoin`, then call `CommitFinalTransaction()` after releasing that lock, just as the accepting-entry action is re-evaluated and finalized.
In `<commit:aaa6d04>`:
- [SUGGESTION] <commit:aaa6d04>:1: Fold the lint-only follow-up into its introducing commits
Commit `aaa6d0464a4` only adds `/* Continued */` markers to four `LogPrint` calls introduced earlier in this stack: one in `96cf3768cab` and three in `7443d022e09`. The linter rejects those unmarked calls, so retaining the separate correction leaves the introducing revisions as lint-failing bisect points and adds review-fix noise to permanent history. Fold each marker into the commit that introduced its call and drop `aaa6d0464a4`.
In `<commit:bb056c6>`:
- [SUGGESTION] <commit:bb056c6>:1: Preserve the rationale for the non-obvious CoinJoin fixes
Commits `bb056c6d397`, `7da61132813`, and `3d5cf6b6e0f` have empty bodies despite changing subtle concurrency and fee-policy invariants. In particular, they serialize timeout reset with finalization, bind entry validation to a session denomination snapshot, and combine guaranteed abort charging with in-flight-message deferral and recoverable-timeout re-evaluation. Add concise bodies explaining the race or policy invariant each commit preserves so ordinary `git log`, blame, and bisect retain the reasoning currently available only from source comments and PR discussion.
| if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) { | ||
| collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT); | ||
| } | ||
| SetNull(); |
There was a problem hiding this comment.
🔴 Blocking: Do not guarantee a fee after the coordinator aborts signing
The guaranteed timeout policy applies even after the server has instructed connected clients to stop signing. RelayFinalTransaction() calls RelayStatus(STATUS_REJECTED) when any entry is disconnected, and ProcessDSSIGNFINALTX() does the same after any AddScriptSig() failure. Connected clients process that rejection by entering POOL_STATE_ERROR and releasing their session resources, but the server remains in POOL_STATE_SIGNING unless every entry is disconnected. A malicious participant can exploit this by submitting its valid signature and then resubmitting it: the duplicate fails, every honest peer is told to abort, and the malicious participant is excluded from the unsigned-offender set. At timeout, this block then guarantees that one honest participant is charged. A failed DSFINALTX relay can similarly charge a connected entry that never received the transaction. Coordinator-originated signing aborts must reset without the guaranteed fee, or eligibility must be limited to participants that received the final transaction and were not subsequently instructed to abort.
source: ['codex']
There was a problem hiding this comment.
Resolved in this update — Do not guarantee a fee after the coordinator aborts signing no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
| if (nState == POOL_STATE_ACCEPTING_ENTRIES) { | ||
| const int entries{GetEntriesCountLocked()}; | ||
| if ((!m_session_collaterals.empty() && size_t(entries) == m_session_collaterals.size()) || | ||
| entries >= CoinJoin::GetMinPoolParticipants()) { | ||
| session_to_finalize = nSessionID; | ||
| charge_fees = size_t(entries) != m_session_collaterals.size(); | ||
| } | ||
| } | ||
|
|
||
| if (session_to_finalize == 0) { | ||
| LogPrint(BCLog::COINJOIN, "CCoinJoinServer::CheckTimeout -- %s timed out -- resetting\n", | ||
| (nState == POOL_STATE_SIGNING) ? "Signing" : "Session"); | ||
| if (nState == POOL_STATE_ACCEPTING_ENTRIES || nState == POOL_STATE_SIGNING) { | ||
| collateral_to_charge = SelectCollateralToCharge(FeePolicy::GUARANTEED_ON_ABORT); | ||
| } | ||
| SetNull(); |
There was a problem hiding this comment.
🔴 Blocking: Commit complete signatures when rechecking a timeout
CheckTimeout() re-evaluates recoverable accepting-entry sessions but does not re-evaluate IsSignaturesComplete() for signing sessions. A scheduler CheckPool() can acquire cs_check_pool, observe the signatures as incomplete, and release cs_coinjoin. The final on-time DSSIGNFINALTX can then add its signature, skip its own CheckPool() because the scheduler still owns cs_check_pool, and clear its in-flight guard. When the scheduler subsequently enters CheckTimeout() after the deadline, there are no unsigned offenders, but this block still calls SetNull() and discards the fully signed transaction. Re-evaluate the signing action under cs_coinjoin, then call CommitFinalTransaction() after releasing that lock, just as the accepting-entry action is re-evaluated and finalized.
source: ['codex']
There was a problem hiding this comment.
Resolved in this update — Commit complete signatures when rechecking a timeout no longer present.
Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.
CheckPool() and CheckForCompleteQueue() read nState, the entry count and the collateral count under separate lock acquisitions (or none at all) and then acted on the result, so a scheduler-thread SetNull() could land between the samples. CheckPool() is the worst case: it sampled nState, then took and released cs_coinjoin for GetEntriesCount(), then read vecSessionCollaterals.size() unlocked. A SetNull() in between made an already-reset session read as '0 entries == 0 collaterals' and get finalized, putting a dead session back into POOL_STATE_SIGNING and rejecting every new dsa until the 15s signing timeout expired. It now decides from one snapshot and acts afterwards, and CreateFinalTransaction()/CommitFinalTransaction() revalidate nSessionID because the decision is made with the lock released. CheckPool() also runs on both the scheduler thread and the message-handling thread, so two concurrent calls could both finalize: clients would receive DSFINALTX twice, sign twice, and the duplicate signatures make AddScriptSig() fail and abort the session for everyone. A TRY_LOCK-only cs_check_pool makes it single-shot without ever blocking msghand. SetState() and IsSessionReady() now require cs_coinjoin, so a transition and the session data it describes can only be observed together; this is what makes the existing revalidation blocks in CreateNewSession()/AddUserToExistingSession() effective. CheckForCompleteQueue() performs its transition under the lock and moves BLS signing and dsq relay outside it. ChargeFees() samples nState once instead of three times, which previously let it select 'didn't send' offenders and then charge and log them as 'didn't sign'.
vecSessionCollaterals had no GUARDED_BY and was reached from both threads with no lock at all: the message-handling thread read it in ProcessDSACCEPT(), IsSessionReady() and AddEntry(), while the scheduler thread read it in CheckPool(), CheckForCompleteQueue(), ChargeFees() and ChargeRandomFees(). The only synchronized access was the clear() in SetNull(). Committing a collateral therefore raced every one of those reads. The worst of them was ChargeRandomFees(), which iterated the vector by reference while calling ConsumeCollateral() - a cs_main mempool submission - for each element. A concurrent SetNull() destroys the CTransactionRefs the loop is walking, so this was a use-after-free and not just a torn size read. It now works from a copy taken under the lock, which also keeps cs_coinjoin from being held across cs_main. The transactions and their prevout index are now a single SessionCollaterals member so they cannot drift apart, and GUARDED_BY on that member makes every access - including the calls on it - checked by -Wthread-safety. Reintroducing an unlocked read is now a compile error rather than a review finding.
AddEntry() checked its bound, then ran IsCollateralValid() and IsValidInOuts() - both of which take cs_main and can block behind block validation - and only then took cs_coinjoin again to push_back. A scheduler-thread CheckTimeout() in that window calls SetNull(), so the entry was committed to a session that no longer existed. The consequence outlives the window: vecEntries keeps the orphaned entry while vecSessionCollaterals is empty, so the next session starts one entry ahead of its own participant count. CheckPool()'s entries == collaterals test then fires early and finalizes a transaction containing an input from the dead session, which nobody present will sign, stalling the new session to its signing timeout and charging its honest participants in ChargeFees(). The bound check and the push_back now share one lock scope, and the session identity captured before validation is rechecked inside it, so an entry can only ever be committed to the session it was validated for.
CheckTimeout() reset the pool while CheckPool() could be finalizing or committing the very same session on the message-handling thread: HasTimedOut() was tested without any lock and the reset could land between CheckPool()'s decision and its execution, clearing a live session mid-step. CheckTimeout() now takes the same cs_check_pool guard as CheckPool() - with TRY_LOCK, so a contended scheduler tick is skipped instead of blocking the message-handling thread - which makes timeout resets and finalize/commit single-flight. Offender selection also moves under cs_coinjoin, into SelectCollateralToCharge(), and into the same lock scope that closes the corresponding admission path: CheckTimeout() selects and resets atomically, and CreateFinalTransaction() selects and transitions to SIGNING atomically, so an entry that crossed the cutoff on time can no longer be charged as missing. The collateral is consumed only after the lock is released, because ConsumeCollateral() takes cs_main and mempool submission must not run under cs_coinjoin.
AddEntry() validated a submission against the live nSessionDenom while holding no session lock: IsValidInOuts() runs long cs_main work, and a scheduler-thread SetNull() in that window zeroes the denomination, so every output of an honest, on-time entry compared unequal to denom 0 and the ERR_DENOM path consumed that participant's collateral for a reset it could not have known about. IsValidInOuts() now takes the denomination as a parameter and AddEntry() passes the snapshot captured under cs_coinjoin alongside the session id it already revalidates before committing, so validation, punishment and commit are all bound to the same session. AddEntry() also rejects submissions up front when the pool is no longer accepting entries, instead of relying on the entries-full bound alone.
Split offender discovery from fee selection so callers can preserve the existing probabilistic policy or request guaranteed charging when a session aborts. Count each signing participant once even if multiple inputs remain unsigned. The previous per-input list gave participants with multiple inputs extra random-selection weight.
A timed-out session was abandoned with only the probabilistic charge, which also skips charging entirely when every participant offended. An attacker who reserved all the slots of a session - or was its sole non-cooperator - could abort session after session at little to no expected cost. Timeout aborts now use FeePolicy::GUARANTEED_ON_ABORT, which always charges exactly one collateral from the offender set; the finalize-with-stragglers path keeps the historical probabilistic policy. Guaranteeing the charge makes two existing races punitive, so both are closed. First, a DSVIN or DSSIGNFINALTX that passed its state check before the deadline can still be validating - including long cs_main work - when the scheduler crosses the timeout, and its sender would be charged as a no-show for a submission the server was actively processing. Such messages now mark themselves in flight under cs_coinjoin at the timeout cutoff, and CheckTimeout() defers while one is pending; InFlightMessageGuard clears the mark on every exit path. Second, because CheckTimeout() and CheckPool() share cs_check_pool, the deadline can be crossed in the exact tick whose CheckPool() round was skipped while a message-handling thread held the guard. CheckTimeout() therefore re-evaluates the accepting-entries action under cs_coinjoin before concluding failure, finalizing a session that has every promised entry - or the required minimum, charging the stragglers through the finalize path - instead of aborting it.
CheckTimeout() treated POOL_STATE_SIGNING past the deadline as a failure without checking whether anything was actually missing. The scheduler's CheckPool() can sample the signatures as incomplete while holding cs_check_pool, the DSSIGNFINALTX that completes them in that window skips its own CheckPool() round because the guard is busy, and the same scheduler pass then enters CheckTimeout() past the deadline with the in-flight mark already cleared. The fully signed transaction was thrown away by SetNull(); with every input signed there was not even an offender to charge for the abort. CheckTimeout() now re-evaluates IsSignaturesComplete() under cs_coinjoin, mirroring the accepting-entries re-evaluation above it, and commits the transaction once the lock is released.
…hans The guaranteed timeout charge assumed a session that dies in SIGNING dies because someone withheld a signature. It can also die because this coordinator told every participant to stand down: a session-wide STATUS_REJECTED - relayed when a signature submission fails validation, or when the final transaction cannot be delivered to a participant - puts honest clients into POOL_STATE_ERROR, where they release their inputs and never sign. At the timeout that followed, exactly those obedient clients formed the didn't-sign offender set and one of them was guaranteed to be charged. A participant who resubmitted its own valid signature could weaponize that: the duplicate fails AddScriptSig(), aborts the session for everyone, and an honest peer pays for it, every session. RelayStatus() now records the session-wide rejection in m_relayed_abort (cleared by SetNull()), and CheckTimeout() skips the guaranteed charge for a session this coordinator already told its participants to abandon. The saboteur does not go free: a signature that fails AddScriptSig() comes from a verified participant of the live signing session and has no honest cause, so ProcessDSSIGNFINALTX() charges that sender's collateral at the moment it forces the abort.
3d5cf6b to
9777136
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (4)
src/test/coinjoin_inouts_tests.cpp (4)
750-781: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueNarrow the
size_tvalues passed toMakeCollateralandMakePeer.
MakeCollateral(uint32_t)receives thesize_tloop variableiat Line 762, andMakePeer(NodeId, uint32_t)receives0x0a000001 + iat Line 766. Both conversions are implicit narrowing. Add explicit casts so the intent is clear and no compiler in CI reports a conversion warning.♻️ Proposed change
- const auto collateral = MakeCollateral(i); + const auto collateral = MakeCollateral(static_cast<uint32_t>(i)); @@ - auto peer = MakePeer(i, 0x0a000001 + i); + auto peer = MakePeer(static_cast<NodeId>(i), static_cast<uint32_t>(0x0a000001 + i));🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/coinjoin_inouts_tests.cpp` around lines 750 - 781, In server_timeout_rechecks_recoverable_session, explicitly narrow the size_t loop variable when passing it to MakeCollateral(uint32_t), and explicitly cast the computed address value passed to MakePeer(NodeId, uint32_t). Preserve the existing loop behavior while eliminating implicit conversion warnings.
188-244: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd negative lock annotations to the new helpers that take
cs_coinjoin.
SeedParticipantat Line 246 declaresEXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin), andRelayAbortForTestat Line 240 follows that pattern. The other new helpers (ResetForTest,SetTimedOutForTest,AddCollateralForTest,AddEntryForTest,SelectForTest,EnterSigningState,SetFinalTransactionForTest,SeedTimedOutSession,MarkMessageInFlightForTest) also acquirecs_coinjoinbut declare no negative capability. Clang thread-safety analysis then cannot detect a caller that already holdscs_coinjoin.♻️ Example annotation for the affected helpers
- void ResetForTest(PoolState state) + void ResetForTest(PoolState state) EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) { LOCK(cs_coinjoin);Also applies to: 253-275
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/coinjoin_inouts_tests.cpp` around lines 188 - 244, Add EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) annotations to ResetForTest, SetTimedOutForTest, AddCollateralForTest, AddEntryForTest, SelectForTest, EnterSigningState, SetFinalTransactionForTest, SeedTimedOutSession, and MarkMessageInFlightForTest, matching the existing annotations on SeedParticipant and RelayAbortForTest. Keep each helper’s internal cs_coinjoin locking unchanged.
321-424: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider splitting this test case into separate scenarios.
server_abort_fee_selects_unique_offendersverifies nine distinct behaviours in one test case: queue timeout, all-missing submitters, one submitter, partial submitters, all submitters, a lone non-signer, weighting across multiple non-signers, the probabilistic gate, andChargeRandomFees. A failure in an early scenario stops the later ones, and the failure output does not name the scenario. SeparateBOOST_AUTO_TEST_CASEblocks per policy scenario would isolate failures. The server construction can move into a small helper to avoid repetition.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/coinjoin_inouts_tests.cpp` around lines 321 - 424, Split server_abort_fee_selects_unique_offenders into separate BOOST_AUTO_TEST_CASE blocks covering each policy scenario, including timeout handling, submission coverage, non-signer selection and weighting, the probabilistic gate, and ChargeRandomFees. Move shared server setup into a small helper to avoid repetition, and retain each scenario’s existing assertions and setup behavior.
609-624: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePrefer an exact integer constant for the expected mempool delta.
static_cast<CAmount>(0.1 * COIN)uses floating point for a consensus-adjacent amount comparison. UseCOIN / 10to keep the expectation exact and independent of double rounding.♻️ Proposed change
- BOOST_CHECK_EQUAL(delta, static_cast<CAmount>(0.1 * COIN)); + BOOST_CHECK_EQUAL(delta, COIN / 10);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test/coinjoin_inouts_tests.cpp` around lines 609 - 624, Replace the floating-point expected amount in the delta assertion after ApplyDelta with the exact integer expression COIN / 10, while preserving the existing CAmount comparison and test behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/test/coinjoin_inouts_tests.cpp`:
- Around line 750-781: In server_timeout_rechecks_recoverable_session,
explicitly narrow the size_t loop variable when passing it to
MakeCollateral(uint32_t), and explicitly cast the computed address value passed
to MakePeer(NodeId, uint32_t). Preserve the existing loop behavior while
eliminating implicit conversion warnings.
- Around line 188-244: Add EXCLUSIVE_LOCKS_REQUIRED(!cs_coinjoin) annotations to
ResetForTest, SetTimedOutForTest, AddCollateralForTest, AddEntryForTest,
SelectForTest, EnterSigningState, SetFinalTransactionForTest,
SeedTimedOutSession, and MarkMessageInFlightForTest, matching the existing
annotations on SeedParticipant and RelayAbortForTest. Keep each helper’s
internal cs_coinjoin locking unchanged.
- Around line 321-424: Split server_abort_fee_selects_unique_offenders into
separate BOOST_AUTO_TEST_CASE blocks covering each policy scenario, including
timeout handling, submission coverage, non-signer selection and weighting, the
probabilistic gate, and ChargeRandomFees. Move shared server setup into a small
helper to avoid repetition, and retain each scenario’s existing assertions and
setup behavior.
- Around line 609-624: Replace the floating-point expected amount in the delta
assertion after ApplyDelta with the exact integer expression COIN / 10, while
preserving the existing CAmount comparison and test behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ec59951d-1248-4c0c-a6ba-d5f91c7b66eb
📒 Files selected for processing (3)
src/coinjoin/server.cppsrc/coinjoin/server.hsrc/test/coinjoin_inouts_tests.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
- src/coinjoin/server.h
- src/coinjoin/server.cpp
|
@PastaPastaPasta this could result in CoinJoin participants being penalized for masternode connectivity issues, right? |
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Codex/Sol only (Phase 2 disabled)
The exact-head Codex revalidation found no actionable defects: the two signing-timeout blockers are fixed, and the rewritten commit stack resolves both history suggestions. All four prior verified findings are resolved at 9777136, so this preliminary gate approves the PR for the next review phase.
Source: reviewer backends: gpt-5.6-sol (general), gpt-5.6-sol (dash-core-commit-history); final verifier backend: gpt-5.6-sol. openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.
Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— dash-core-commit-history (completed) - Verifier:
gpt-5.6-sol— verifier - Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
- Secondary pass: disabled (
temporary_phase2_sonnet_disable)
Issue being fixed or feature implemented
CoinJoin participants can currently reserve a coordinator slot and then abort during entry submission or signing without necessarily losing collateral. In particular, the existing probabilistic policy exempts sessions where every participant is an offender, allowing coordinated non-cooperation to repeatedly kill sessions without cost.
This PR is intentionally built on #7537, which makes fee selection and session reset atomic. It should be reviewed and merged after that prerequisite.
What was done?
PROBABILISTICandGUARANTEED_ON_ABORTmodes.SetNull()undercs_coinjoin, then consume the selected collateral after releasing the lock.STATUS_REJECTED): honest clients obey the abort, release their inputs, and stop signing, so they must not form the offender set. The participant whose invalid signature forced the abort is charged directly inProcessDSSIGNFINALTX()instead.IsSignaturesComplete()whenCheckTimeout()inspects a signing session, and commit a fully signed transaction instead of discarding it — the finalDSSIGNFINALTXcan land in a scheduler round whoseCheckPool()already sampled the signatures as incomplete.server.cpphead so the stacked branch compiles.How Has This Been Tested?
Built
src/test/test_dashlocally on macOS arm64 using the prebuilt depends prefix, then ran:The unit coverage exercises queue timeouts, all/many/few/no missing entries, lone and multiple non-signers, deduplication of participants with several unsigned inputs, all-participant signing failure, timeout/reset atomicity, the recoverable probabilistic policy, successful-session random charging, committing a fully signed session at timeout, direct saboteur charging, and forgoing the guaranteed charge after a relayed abort.
Breaking Changes
No wire-format, wallet, database, persistent-format, or consensus change. Mixed-version operation remains safe; only upgraded masternodes apply the guaranteed failed-session fee.
Checklist:
This pull request was created by Codex.